import { firebaseConfig } from '/auth/firebase-config.js';
import { rewardsConfig } from '/js_v3/rewards-config.js';
import { getApps, getApp, initializeApp } from 'https://www.gstatic.com/firebasejs/12.11.0/firebase-app.js';
import { getAuth, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/12.11.0/firebase-auth.js';
import * as cardModule from './cards-data.js';
import { missionCatalog } from './missions-data.js';
const app = getApps().length ? getApp() : initializeApp(firebaseConfig);
const auth = getAuth(app);
const PACKS = [
{ id: 'standard', name: 'Sobre básico', price: 5, subtitle: '5 cartas normales', pitch: '41% común · 27% poco común · 18% rara · 10% épica · 4% legendaria.', bullets: ['No salen cartas Plata ni Oro', 'Consume primero MAJ si tienes', 'Las repetidas sirven para fusionar', 'Pequeña posibilidad de vestuario alternativo'], enabled: true },
{ id: 'gala', name: 'Sobre de gala', price: 10, subtitle: 'Mejores rarezas + vestuario', pitch: '30% común · 25% poco común · 20% rara · 15% épica · 10% legendaria. 50% de intentar imagen alternativa si el personaje tiene.', bullets: ['No salen Plata/Oro directamente', '50% de vestuario alternativo si existe', 'Consume primero MAJ', 'Más útil para completar colección'], enabled: true },
{ id: 'fayenne_promo', name: 'Sobre promocional Fayenne', price: 10, subtitle: '80% personajes del grupo FAYENNE', pitch: '80% del grupo FAYENNE con proporciones básicas. 20% general limitado a común/poco común/rara.', bullets: ['Ideal para buscar personajes de la serie', 'No salen Plata/Oro directamente', 'El 20% general no da épicas/legendarias', 'Consume primero MAJ'], enabled: true },
];
const RARITY_ORDER = ['Común', 'Poco común', 'Rara', 'Épica', 'Legendaria', 'Especial'];
const RARITY_SCORE = { 'Común': 1, 'Poco común': 2, 'Rara': 4, 'Épica': 7, 'Legendaria': 11, 'Especial': 13 };
const VARIANT_SCORE = { 'Normal': 0, 'Plata': 3, 'Oro': 8, 'Especial': 10 };
const CACHE_KEY = 'fayenne_cardgame_cache_v81';
const DECK_KEY = 'fayenne_cardgame_deck_v81';
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
const esc = (v = '') => String(v ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const number = v => { const n = Number(v || 0); return Number.isFinite(n) ? n : 0; };
const sum = arr => arr.reduce((a, b) => a + b, 0);
const rnd = (min, max) => Math.floor(min + Math.random() * (max - min + 1));
const cap = s => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1);
const norm = s => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
const slug = s => norm(s).replace(/\s+/g, '-') || 'item';
function rawCardsFromModule() {
return cardModule.cardsData || cardModule.cardGameData?.cardsData || cardModule.cardGameData?.cards || cardModule.cards || [];
}
function rawGroupsFromModule() {
return cardModule.groupCatalog || cardModule.cardGameData?.groupCatalog || [];
}
function rawGroupRulesFromModule() {
return cardModule.groupRules || cardModule.cardGameData?.groupRules || [];
}
function rawGroupBonusRulesFromModule() {
return cardModule.groupBonusRules || cardModule.cardGameData?.groupBonusRules || [];
}
function rawPairRulesFromModule() {
return cardModule.pairRules || cardModule.cardGameData?.pairRules || [];
}
function rawSkinsFromModule() {
return cardModule.skinData || cardModule.skins || cardModule.cardGameData?.skinData || cardModule.cardGameData?.skins || [];
}
function cleanCategory(c) {
const v = String(c || 'Normal').trim();
if (/plata/i.test(v)) return 'Plata';
if (/oro/i.test(v)) return 'Oro';
if (/especial/i.test(v)) return 'Especial';
return 'Normal';
}
function cardBaseId(c) { return c?.baseId || c?.variantOf || slug(c?.baseName || c?.name || c?.id); }
function normalizeCard(c) {
const baseId = cardBaseId(c);
const category = cleanCategory(c.category || c.variantLabel || c.variantType || 'Normal');
return {
...c,
baseId,
variantOf: c.variantOf || baseId,
baseName: c.baseName || c.name || baseId,
category,
variantLabel: c.variantLabel || category,
rarity: c.rarity || 'Común',
groups: Array.isArray(c.groups) ? c.groups.filter(Boolean) : [],
packEligible: c.packEligible ?? (category === 'Normal' && !c.isSpecial && c.obtainable !== false),
upgradeCost: c.upgradeCost || 5,
imageVariants: Array.isArray(c.imageVariants) ? c.imageVariants : [],
};
}
function normalizeGroup(g) {
const name = String(g?.name || g?.group || g || '').trim();
const id = g?.id || slug(name);
return { ...(typeof g === 'object' && g ? g : {}), id, name, group: name, icon: g?.icon || `/assets/grupos/${slug(name)}.png`, description: g?.description || '', enabled: g?.enabled !== false };
}
function bonusListToEffects(list = [], type = 'Bonus') {
const effects = { pools: {}, bonuses: {}, autoSuccesses: {}, wildcardPool: 0, wildcardBonus: 0, groupPV: 0, allSkills: 0 };
if (/penal/i.test(type) && (!list || !list.length)) effects.allSkills -= 1;
for (const b of (list || [])) {
const kind = String(b.bonusType || b.type || 'directo').toLowerCase();
const section = String(b.section || b.category || '').trim();
const skill = String(b.skill || '').trim();
const value = number(b.value || b.amount || 1) || 1;
const key = section && skill ? `${section}.${skill}` : section;
if (kind.includes('penal')) effects.allSkills -= Math.abs(value);
else if (kind.includes('pull')) effects.pools[section || skill || 'comodin'] = number(effects.pools[section || skill || 'comodin']) + value;
else if (kind.includes('exito') || kind.includes('auto')) effects.autoSuccesses[key || section || skill] = number(effects.autoSuccesses[key || section || skill]) + value;
else if (kind.includes('comod')) effects.wildcardPool += value;
else if (kind.includes('pv')) effects.groupPV += value;
else if (key) effects.bonuses[key] = number(effects.bonuses[key]) + value;
}
return effects;
}
function normalizeGroupRules() {
const direct = rawGroupRulesFromModule();
if (direct.length && direct[0]?.effects) return direct;
const source = rawGroupBonusRulesFromModule();
return source.map(r => ({
group: r.group || r.name,
rule: r.rule || 'members_minus_one',
minMembers: number(r.minMembers || 2),
stacking: r.stacking || { directBonuses: 'not_stack_same_bonus', pulls: 'stack_members_minus_one', groupPV: 'shared_pool' },
effects: bonusListToEffects(r.bonuses || [], 'Bonus'),
raw: r,
}));
}
function normalizePairRules() {
return rawPairRulesFromModule().map(pr => {
if (pr.effects && pr.receiver && pr.related) return pr;
const receiver = pr.receiver || pr.receiverKey || slug(pr.receiverName || '');
const related = pr.related || pr.with || pr.withKey || slug(pr.withName || '');
return { receiver: slug(receiver), related: slug(related), type: pr.type || 'Bonus', raw: `${pr.receiverName || receiver} - ${pr.withName || related}`, effects: bonusListToEffects(pr.bonuses || [], pr.type || 'Bonus'), original: pr };
}).filter(x => x.receiver && x.related);
}
function buildSkins(cards) {
const out = [];
const seen = new Set();
// V6.0: mapa defensivo para detectar imágenes de evolución aunque el editor
// las exporte como "Imagen base" o como skin normal.
const evolutionImageCategory = new Map();
const evolutionCardByImage = new Map();
for (const raw of cards) {
const c = raw || {};
const baseId = c.baseId || c.variantOf || slug(c.baseName || c.name || c.id || '');
const cat = cleanCategory(c.category || c.variantLabel || 'Normal');
if (!baseId || (cat !== 'Plata' && cat !== 'Oro')) continue;
const images = [c.image, ...(Array.isArray(c.imageVariants) ? c.imageVariants.map(v => v && v.image) : [])].filter(Boolean);
for (const img of images) {
const key = `${baseId}::${String(img).trim()}`;
evolutionImageCategory.set(key, cat);
evolutionCardByImage.set(key, c.id);
}
}
const inferEvolutionCategory = (s = {}, baseId = '') => {
const text = `${s.id || ''} ${s.label || ''} ${s.kind || ''} ${s.image || ''} ${s.unlockCategory || ''}`;
if (/plata/i.test(text)) return 'Plata';
if (/oro/i.test(text)) return 'Oro';
const byImage = evolutionImageCategory.get(`${baseId}::${String(s.image || '').trim()}`);
return byImage || cleanCategory(s.unlockCategory || 'Normal');
};
const addSkin = (s = {}) => {
if (!s.image) return;
const baseId = s.baseId || s.variantOf || slug(s.baseName || '');
if (!baseId) return;
const id = s.id || `${baseId}_${slug(s.label || s.kind || 'imagen')}`;
if (seen.has(id)) return;
seen.add(id);
const unlockCategory = inferEvolutionCategory(s, baseId);
const requiresEvolution = unlockCategory === 'Plata' || unlockCategory === 'Oro';
const baseLike = /_normal$|_base$/i.test(id);
const imageKey = `${baseId}::${String(s.image || '').trim()}`;
out.push({
...s,
id,
baseId,
label: s.label || 'Imagen',
image: s.image,
weight: number(s.weight || 1),
kind: requiresEvolution ? slug(unlockCategory) : (s.kind || 'vestuario'),
default: requiresEvolution ? false : (!!s.default || baseLike),
free: requiresEvolution ? false : (!!s.free || !!s.default || baseLike),
unlockCategory,
unlockedByCardId: s.unlockedByCardId || evolutionCardByImage.get(imageKey),
});
};
// 1) Mantiene las skins exportadas por el editor, pero corrige las que sean imágenes de Plata/Oro.
for (const s of rawSkinsFromModule()) addSkin(s);
// 2) Añade automáticamente las imágenes propias de Normal / Plata / Oro / Especial.
for (const c of cards) {
const category = cleanCategory(c.category || c.variantLabel || 'Normal');
const categorySlug = slug(category);
const isNormal = category === 'Normal';
// Las variantes de vestuario solo se leen de la carta Normal.
// Las cartas Plata/Oro solo aportan su imagen principal, para evitar duplicados desbloqueados.
if (isNormal) {
for (const v of (c.imageVariants || [])) {
if (v.enabled === false || !v.image) continue;
const localId = v.id || slug(v.label || 'base');
const id = `${c.baseId}_${localId}`;
addSkin({
id,
baseId: c.baseId,
label: v.label || 'Imagen normal',
image: v.image,
weight: number(v.weight || 1),
kind: v.kind || 'base',
// V6.1: solo la imagen base/default es gratis. El resto de vestuarios se desbloquean en sobres/eventos/admin.
default: v.id === c.defaultImageVariant || v.kind === 'base' || localId === 'base',
free: v.kind === 'base' || localId === 'base' || !!v.default,
unlockCategory: 'Normal',
unlockedByCardId: (v.kind === 'base' || localId === 'base' || !!v.default) ? c.id : '',
});
}
}
if (c.image) {
const id = isNormal ? `${c.baseId}_base` : `${c.baseId}_${categorySlug}`;
addSkin({
id,
baseId: c.baseId,
label: isNormal ? 'Imagen normal' : `Imagen ${category}`,
image: c.image,
weight: isNormal ? 100 : 1,
kind: isNormal ? 'base' : categorySlug,
default: isNormal,
free: isNormal,
unlockCategory: category,
unlockedByCardId: c.id,
});
}
}
return out;
}
const cardsData = rawCardsFromModule().map(normalizeCard);
const groupCatalog = rawGroupsFromModule().map(normalizeGroup);
const groupRules = normalizeGroupRules();
const pairRules = normalizePairRules();
const byId = Object.fromEntries(cardsData.map(c => [c.id, c]));
const normalCards = cardsData.filter(c => c.category === 'Normal');
const skinData = buildSkins(cardsData);
const skinsById = Object.fromEntries(skinData.map(s => [s.id, s]));
const skinsByBase = skinData.reduce((acc, s) => { (acc[s.baseId] ||= []).push(s); return acc; }, {});
const groupsByNorm = Object.fromEntries(groupCatalog.map(g => [norm(g.name), g]));
const state = {
user: null,
wallet: { ma: 0, maFree: 0, maPaid: 0 },
player: { collection: {}, stats: { packsOpened: 0, missionsWon: 0 }, starterClaimed: false, selectedSkins: {}, ownedSkins: {} },
deck: loadDeck(),
filters: { rarity: 'all', variant: 'all', owned: 'owned', search: '' },
};
const ADMIN_EMAILS = ['acquimera@gmail.com', 'leyendasdehidros@gmail.com'];
function isAdminUser() { return ADMIN_EMAILS.includes(String(state.user?.email || '').toLowerCase()); }
// Protección anti-autofill: Chrome/Edge a veces mete el usuario de Gmail en el primer buscador.
// Solo aceptamos texto si viene después de una acción real de teclado/pegado.
let ccSearchUserTouched = false;
let ccSearchProtectionUntil = Date.now() + 2500;
function hardResetSearch(reason = '') {
const input = document.getElementById('ccSearchInput');
if (!input) return;
if (!ccSearchUserTouched) {
input.value = '';
state.filters.search = '';
}
}
function enableSearchManualInput() {
const input = document.getElementById('ccSearchInput');
if (!input) return;
input.removeAttribute('readonly');
}
function apiBase() { return String(rewardsConfig.functionsBaseUrl || '').replace(/\/$/, ''); }
function saveCache(payload) { try { localStorage.setItem(CACHE_KEY, JSON.stringify({ t: Date.now(), ...payload })); } catch (_) {} }
function loadCache() { try { return JSON.parse(localStorage.getItem(CACHE_KEY) || 'null'); } catch (_) { return null; } }
function saveDeck() { try { localStorage.setItem(DECK_KEY, JSON.stringify(state.deck)); } catch (_) {} }
function loadDeck() { try { const raw = JSON.parse(localStorage.getItem(DECK_KEY) || 'null'); if (raw?.active && raw?.reserve) return raw; } catch (_) {} return { active: [], reserve: [] }; }
function ownedQty(cardId) { return number(state.player.collection?.[cardId]?.qty || state.player.collection?.[cardId] || 0); }
function isOwned(cardId) { return ownedQty(cardId) > 0; }
function totalOwnedCards() { return sum(Object.values(state.player.collection || {}).map(v => number(v?.qty || v))); }
function uniqueOwnedCards() { return Object.keys(state.player.collection || {}).filter(k => ownedQty(k) > 0).length; }
function collectionPower() { return cardsData.reduce((acc, c) => acc + ownedQty(c.id) * ((RARITY_SCORE[c.rarity] || 0) + (VARIANT_SCORE[c.category] || 0) + 1), 0); }
function baseOf(cardOrId) { const c = typeof cardOrId === 'string' ? byId[cardOrId] : cardOrId; return cardBaseId(c); }
function findEvolutionTarget(card) {
if (!card) return null;
const next = card.category === 'Normal' ? 'Plata' : card.category === 'Plata' ? 'Oro' : null;
if (!next) return null;
const base = baseOf(card);
return cardsData.find(c => baseOf(c) === base && c.category === next) || byId[`${base}_${next.toLowerCase()}`] || byId[`${base}-${next.toLowerCase()}`] || null;
}
function canEvolve(card) { const target = findEvolutionTarget(card); return !!target && ownedQty(card.id) >= number(target.upgradeCost || 5); }
function evolutionLabel(card) { const t = findEvolutionTarget(card); return t ? `Fusionar a ${t.category}` : 'Fusionar'; }
const EVOLUTION_CATEGORIES = ['Normal', 'Plata', 'Oro'];
function cardByBaseCategory(baseId, category) {
const b = String(baseId || '');
return cardsData.find(c => baseOf(c) === b && c.category === category) || null;
}
function qtyByBaseCategory(baseId, category) {
const c = cardByBaseCategory(baseId, category);
return c ? ownedQty(c.id) : 0;
}
function ordinaryCardsForBase(baseId) {
return EVOLUTION_CATEGORIES.map(cat => cardByBaseCategory(baseId, cat)).filter(Boolean);
}
function highestOwnedOrdinaryCard(baseId, includeMissing = false) {
for (const cat of ['Oro', 'Plata', 'Normal']) {
const c = cardByBaseCategory(baseId, cat);
if (c && ownedQty(c.id) > 0) return c;
}
return includeMissing ? (cardByBaseCategory(baseId, 'Normal') || ordinaryCardsForBase(baseId)[0] || null) : null;
}
function baseHasAnyOwned(baseId) {
return ordinaryCardsForBase(baseId).some(c => ownedQty(c.id) > 0);
}
function baseEvolutionOptions(baseId) {
const normal = cardByBaseCategory(baseId, 'Normal');
const silver = cardByBaseCategory(baseId, 'Plata');
const gold = cardByBaseCategory(baseId, 'Oro');
const out = [];
if (normal && silver) {
const cost = number(silver.upgradeCost || 5);
out.push({ source: normal, target: silver, sourceCategory: 'Normal', targetCategory: 'Plata', cost, available: qtyByBaseCategory(baseId, 'Normal') });
}
if (silver && gold) {
const cost = number(gold.upgradeCost || 5);
out.push({ source: silver, target: gold, sourceCategory: 'Plata', targetCategory: 'Oro', cost, available: qtyByBaseCategory(baseId, 'Plata') });
}
return out;
}
function bestPlayableCardForBase(baseId) {
return highestOwnedOrdinaryCard(baseId, true);
}
function evolutionCounterHtml(baseId) {
const parts = EVOLUTION_CATEGORIES.map(cat => {
const c = cardByBaseCategory(baseId, cat);
if (!c) return '';
const q = ownedQty(c.id);
if (!q && cat !== 'Normal') return '';
const cls = cat === 'Oro' ? 'cc-tier-gold' : cat === 'Plata' ? 'cc-tier-silver' : 'cc-tier-normal';
return `${esc(cat)} x${q} `;
}).filter(Boolean);
return parts.length ? `
${parts.join('')}
` : '';
}
function hasOwnedCardForSkin(s = {}) {
if (s.unlockedByCardId && ownedQty(s.unlockedByCardId) > 0) return true;
if (s.unlockCategory) {
const c = cardByBaseCategory(s.baseId, s.unlockCategory);
if (c && ownedQty(c.id) > 0) return true;
}
return false;
}
function skinRequiresEvolution(s = {}) {
const cat = cleanCategory(s.unlockCategory || 'Normal');
const kind = String(s.kind || '').toLowerCase();
return cat === 'Plata' || cat === 'Oro' || kind === 'plata' || kind === 'oro';
}
function isSkinOwned(skinId) {
const s = skinsById[skinId];
if (!s) return false;
// V6.1: las imágenes de evolución se desbloquean por tener Plata/Oro.
if (skinRequiresEvolution(s)) return hasOwnedCardForSkin(s);
// Las skins normales NO se desbloquean solo por tener la carta. Deben ser base/free o estar ganadas en sobre/evento/admin.
return !!state.player.ownedSkins?.[skinId] || !!s.free || !!s.default;
}
function categorySkinForCard(card) {
const cat = cleanCategory(card?.category || 'Normal');
if (!card || cat === 'Normal') return null;
const list = skinsByBase[card.baseId] || [];
return list.find(s => isSkinOwned(s.id) && (s.unlockedByCardId === card.id || s.unlockCategory === cat || s.kind === slug(cat)));
}
function resolveImage(card, forcedSkinId = '') {
if (!card) return '';
if (forcedSkinId && skinsById[forcedSkinId]) return skinsById[forcedSkinId].image;
if (card.category !== 'Especial') {
const selected = state.player.selectedSkins?.[card.baseId];
if (selected && isSkinOwned(selected) && skinsById[selected]) return skinsById[selected].image;
const byCategory = categorySkinForCard(card);
if (byCategory) return byCategory.image;
const def = (skinsByBase[card.baseId] || []).find(s => (s.default || s.free) && isSkinOwned(s.id));
if (def) return def.image;
}
return card.image;
}
function displayVariant(card) { return card.category || card.variantLabel || card.variantType || 'Normal'; }
function groupIcon(name) { return groupsByNorm[norm(name)]?.icon || `/assets/grupos/${slug(name)}.png`; }
function renderGroupIcons(groups = [], max = 8) {
const list = (groups || []).slice(0, max);
if (!list.length) return 'Sin grupos ';
return `${list.map(g => {
const initials = String(g || '?').split(/\s+/).map(x=>x[0]).join('').slice(0,3).toUpperCase();
return `
${esc(initials || g)} `;
}).join('')}
`;
}
function normalizeState(data = {}) {
state.wallet = { ma: number(data.wallet?.ma), maFree: number(data.wallet?.maFree), maPaid: number(data.wallet?.maPaid), maGame: number(data.wallet?.maGame ?? data.wallet?.maj) };
const p = data.player || {};
state.player = {
collection: p.collection || {},
stats: { packsOpened: number(p.stats?.packsOpened), missionsWon: number(p.stats?.missionsWon) },
starterClaimed: !!p.starterClaimed,
selectedSkins: p.selectedSkins || {},
ownedSkins: p.ownedSkins || {},
};
if (!state.deck.active.length && uniqueOwnedCards()) autoDeck(false);
pruneDeck();
saveCache({ wallet: state.wallet, player: state.player });
}
function pruneDeck() {
const seen = new Set();
state.deck.active = (state.deck.active || []).filter(id => isOwned(id) && byId[id] && !seen.has(baseOf(id)) && seen.add(baseOf(id))).slice(0, 5);
state.deck.reserve = (state.deck.reserve || []).filter(id => isOwned(id) && byId[id] && !seen.has(baseOf(id)) && seen.add(baseOf(id))).slice(0, 5);
saveDeck();
}
async function callApi(path, body = {}) {
if (!state.user) throw new Error('Inicia sesión primero.');
const token = await state.user.getIdToken();
let res;
try {
res = await fetch(`${apiBase()}/${path}`, { method: 'POST', mode: 'cors', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body) });
} catch (err) {
throw new Error('El backend no ha respondido. Despliega functions y prueba otra vez.');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Error de servidor.');
return data;
}
function toast(text, bad = false) {
let el = document.getElementById('ccToast');
if (!el) { el = document.createElement('div'); el.id = 'ccToast'; Object.assign(el.style, { position:'fixed', right:'18px', bottom:'18px', zIndex:'1700', padding:'14px 16px', borderRadius:'16px', maxWidth:'520px', fontWeight:'800', boxShadow:'0 18px 45px rgba(0,0,0,.35)', transition:'all .2s ease' }); document.body.appendChild(el); }
el.textContent = text; el.style.background = bad ? 'rgba(72,8,18,.96)' : 'rgba(20,58,30,.96)'; el.style.border = bad ? '1px solid rgba(255,141,152,.35)' : '1px solid rgba(145,243,161,.35)'; el.style.color = '#fff'; el.style.opacity='1'; clearTimeout(el._t); el._t=setTimeout(()=>el.style.opacity='0', 3200);
}
function modal(html) { const root = $('#ccModalRoot'); root.innerHTML = ``; $('.cc-modal__close', root)?.addEventListener('click', closeModal); $('.cc-modal', root)?.addEventListener('click', e => { if (e.target.classList.contains('cc-modal')) closeModal(); }); }
function closeModal() { $('#ccModalRoot').innerHTML = ''; }
function renderWalletPanel() {
const el = $('#ccWalletPanel'); if (!el) return;
if (!state.user) { el.innerHTML = `Invitado
Inicia sesión Puedes ver el álbum, pero para guardar colección, abrir sobres y fusionar cartas necesitas sesión.
`; return; }
const adminTools = isAdminUser() ? `` : '';
const maGame = number(state.wallet.maGame);
el.innerHTML = `Conectado ${esc(state.user.email || '')}
❤️ ${number(state.wallet.ma)}
MAJ de juego 🪙 ${maGame}
Disponible en juegos ${number(state.wallet.ma) + maGame}
MA gratis ${number(state.wallet.maFree)}
MA de apoyo ${number(state.wallet.maPaid)}
Únicas: ${uniqueOwnedCards()}
Sobres: ${number(state.player.stats?.packsOpened)}
${adminTools}`;
bindAdminCardButtons();
}
async function runAdminCardAction(action, extra = {}) {
const labels = {
resetSelf: 'resetear la colección de pruebas',
clearEvolutions: 'limpiar evoluciones Plata/Oro',
repairCollection: 'reparar/normalizar la colección',
addTestCards: `añadir ${extra.qty || 5} copias de prueba`,
addTestSkin: 'desbloquear una imagen/skin de prueba',
clearTestSkins: 'quitar skins de prueba/desbloqueadas',
};
const label = labels[action] || 'ejecutar la herramienta admin';
if (!confirm(`¿Seguro que quieres ${label}? Solo afecta a esta cuenta admin.`)) return;
try {
const data = await callApi('admin-card-maintenance', { action, ...extra });
normalizeState(data);
clearDeck();
renderAll();
toast(data.message || 'Herramienta admin ejecutada.');
} catch (err) {
toast(err.message || 'Error admin.', true);
}
}
function bindAdminCardButtons() {
$$('[data-admin-card-action]').forEach(btn => {
if (btn.dataset.bound === '1') return;
btn.dataset.bound = '1';
btn.addEventListener('click', () => runAdminCardAction(btn.dataset.adminCardAction, {
baseId: btn.dataset.adminBaseId || '',
cardId: btn.dataset.adminCardId || '',
category: btn.dataset.adminCategory || 'Normal',
qty: number(btn.dataset.adminQty || 5),
skinId: btn.dataset.adminSkinId || '',
}));
});
}
function renderStats() {
const el = $('#ccStatsStrip'); if (!el) return;
const ordinaryBases = [...new Set(cardsData.filter(c => EVOLUTION_CATEGORIES.includes(c.category)).map(c => baseOf(c)))];
const basesOwned = ordinaryBases.filter(baseHasAnyOwned).length;
const silverOwned = ordinaryBases.filter(b => qtyByBaseCategory(b, 'Plata') > 0).length;
const goldOwned = ordinaryBases.filter(b => qtyByBaseCategory(b, 'Oro') > 0).length;
const evolvable = ordinaryBases.filter(b => baseEvolutionOptions(b).some(o => o.available >= o.cost)).length;
const stats = [['Personajes', `${basesOwned}/${ordinaryBases.length}`, 'Álbum por personaje'], ['Copias totales', totalOwnedCards(), 'Incluye material de fusión'], ['Plata/Oro', `${silverOwned}/${goldOwned}`, 'Evoluciones obtenidas'], ['Fusionables', evolvable, 'Listos para mejorar']];
el.innerHTML = stats.map(([a,b,c]) => `${esc(a)}
${esc(b)}
${esc(c)}
`).join('');
}
function renderPacks() {
const wrap = $('#ccPackGrid'); if (!wrap) return;
wrap.innerHTML = PACKS.map(pack => `${pack.enabled?'Disponible':'Preparado'}
${esc(pack.name)}
${esc(pack.subtitle)}
${pack.price} MA/MAJ
${esc(pack.pitch)}
${pack.bullets.map(x=>`${esc(x)} `).join('')} Abrir
`).join('');
$$('[data-pack-open]').forEach(btn => btn.addEventListener('click', () => openPackFlow(btn.dataset.packOpen)));
}
function renderMissions() {
const wrap = $('#ccMissionCards'); if (!wrap) return;
wrap.innerHTML = missionCatalog.map(m => `${esc(m.mode)} Equipo ${m.teamSize} Reserva ${m.reserveSize}
${esc(m.name)} ${esc(m.description)}
${Object.values(m.categories).map(c=>`${esc(c.label)} `).join('')}
Jugar `).join('');
$$('[data-mission-play]').forEach(btn => btn.addEventListener('click', () => openMissionModal(btn.dataset.missionPlay)));
}
function cardSortScore(card) { return (RARITY_SCORE[card.rarity] || 0) * 100 + (VARIANT_SCORE[card.category] || 0) * 20 + totalCardValue(card); }
function totalCardValue(card) { return sum(Object.values(card.day||{}).flatMap(o=>Object.values(o||{})).map(number)) + sum(Object.values(card.night||{}).flatMap(o=>Object.values(o||{})).map(number)); }
function renderCollectionAdminTools() {
const el = $('#ccCollectionAdminTools');
if (!el) return;
if (!isAdminUser()) { el.innerHTML = ''; return; }
el.innerHTML = ``;
bindAdminCardButtons();
}
function renderCollection() {
const wrap = $('#ccCollectionGrid'); if (!wrap) return;
const search = state.filters.search.trim().toLowerCase();
const ordinaryBases = [...new Set(cardsData.filter(c => EVOLUTION_CATEGORIES.includes(c.category)).map(c => baseOf(c)))];
let list = ordinaryBases.map(baseId => ({ baseId, card: highestOwnedOrdinaryCard(baseId, state.filters.owned === 'all') })).filter(x => x.card);
// Las cartas especiales siguen existiendo como versiones con reglas propias, pero las evoluciones Normal/Plata/Oro se muestran agrupadas.
const specialCards = cardsData.filter(c => c.category === 'Especial').map(c => ({ baseId: baseOf(c), card: c, special: true }));
list = list.concat(specialCards.filter(x => state.filters.owned === 'all' || isOwned(x.card.id)));
list = list.filter(({ baseId, card, special }) => {
if (state.filters.owned === 'owned') {
if (special) { if (!isOwned(card.id)) return false; }
else if (!baseHasAnyOwned(baseId)) return false;
}
if (state.filters.rarity !== 'all' && card.rarity !== state.filters.rarity) return false;
if (state.filters.variant !== 'all') {
if (special) { if (card.category !== state.filters.variant) return false; }
else if (state.filters.variant !== 'Normal' && qtyByBaseCategory(baseId, state.filters.variant) <= 0) return false;
}
if (search) {
const groupText = (card.groups || []).join(' ');
const hay = `${card.name} ${card.baseName} ${card.category} ${card.rarity} ${groupText} ${card.description || ''}`.toLowerCase();
if (!hay.includes(search)) return false;
}
return true;
}).sort((a,b) => cardSortScore(b.card)-cardSortScore(a.card));
if (!list.length) { wrap.innerHTML = `No hay cartas con esos filtros.
`; return; }
wrap.innerHTML = list.map(({ baseId, card, special }) => {
const qty = special ? ownedQty(card.id) : Math.max(ownedQty(card.id), sum(ordinaryCardsForBase(baseId).map(c => ownedQty(c.id))));
const playable = special ? card : bestPlayableCardForBase(baseId);
const inDeck = playable && (state.deck.active.includes(playable.id) || state.deck.reserve.includes(playable.id));
const options = special ? [] : baseEvolutionOptions(baseId).filter(o => o.available >= o.cost);
const evoButtons = options.map(o => `${o.cost} ${o.sourceCategory} → ${o.targetCategory} `).join('');
const adminButtons = isAdminUser() && !special ? `Admin +5 Normal Admin +5 Plata ` : '';
const missing = state.filters.owned === 'all' && !special && !baseHasAnyOwned(baseId);
return `
x${qty}
${esc((card.baseName || card.name).replace(/\s*\((Plata|Oro)\)\s*$/i,''))} ${esc(card.rarity)}
${special ? esc(displayVariant(card)) : 'Personaje evolucionable'}
${renderGroupIcons(card.groups, 5)}
${special ? '' : evolutionCounterHtml(baseId)}
${esc(displayVariant(card))} ${card.packEligible?'sobre ':'fusión/evento '}
Ver
${inDeck?'Quitar':'Equipo'}
${evoButtons}
${adminButtons}
`;
}).join('');
$$('[data-view-card]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); openCardModal(btn.dataset.viewCard); }));
$$('[data-deck-card]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); toggleDeckCard(btn.dataset.deckCard); }));
$$('[data-evolve-base]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); evolveBase(btn.dataset.evolveBase, btn.dataset.targetCategory); }));
bindAdminCardButtons();
}
function renderDeck() {
const active = $('#ccActiveSlots'), reserve = $('#ccReserveSlots'); if (!active || !reserve) return;
active.innerHTML = Array.from({ length: 5 }, (_, i) => renderSlot(state.deck.active[i], 'active', i)).join('');
reserve.innerHTML = Array.from({ length: 5 }, (_, i) => renderSlot(state.deck.reserve[i], 'reserve', i)).join('');
$$('[data-slot-remove]').forEach(btn => btn.addEventListener('click', () => removeFromDeck(btn.dataset.slotRemove, Number(btn.dataset.slotIndex))));
renderResourcePanel();
}
function renderSlot(cardId, type, i) {
if (!cardId || !byId[cardId]) return `${type==='active'?'Activo':'Reserva'} ${i+1}
Hueco libre
`;
const c = byId[cardId];
return `${esc(c.name)} ${esc(displayVariant(c))}
Quitar `;
}
function autoDeck(render=true) { const best = cardsData.filter(c => isOwned(c.id)).sort((a,b)=>cardSortScore(b)-cardSortScore(a)); const seen=new Set(); const chosen=[]; for (const c of best) { if (seen.has(baseOf(c))) continue; chosen.push(c.id); seen.add(baseOf(c)); } state.deck.active=chosen.slice(0,5); state.deck.reserve=chosen.slice(5,10); saveDeck(); if (render) renderAll(); }
function clearDeck() { state.deck={active:[],reserve:[]}; saveDeck(); renderAll(); }
function removeFromDeck(type, index) { state.deck[type].splice(index,1); saveDeck(); renderAll(); }
function toggleDeckCard(cardId) {
if (!isOwned(cardId)) return;
if (state.deck.active.includes(cardId)) state.deck.active = state.deck.active.filter(id=>id!==cardId);
else if (state.deck.reserve.includes(cardId)) state.deck.reserve = state.deck.reserve.filter(id=>id!==cardId);
else {
const base = baseOf(cardId);
const exists = [...state.deck.active, ...state.deck.reserve].some(id => baseOf(id) === base);
if (exists) { toast('No puedes repetir el mismo personaje base en el equipo.', true); return; }
if (state.deck.active.length < 5) state.deck.active.push(cardId);
else if (state.deck.reserve.length < 5) state.deck.reserve.push(cardId);
else { toast('Equipo y reserva llenos.', true); return; }
}
saveDeck(); renderAll();
}
function applyEffectToAccumulator(acc, effects={}, poolScale=1, label='', bonusScale=1) {
if (!effects) return;
acc.wildcardPool += number(effects.wildcardPool) * poolScale;
acc.wildcardBonus += number(effects.wildcardBonus) * bonusScale;
acc.groupPV += number(effects.groupPV) * poolScale;
for (const [k,v] of Object.entries(effects.pools||{})) acc.pools[k] = number(acc.pools[k]) + number(v)*poolScale;
for (const [k,v] of Object.entries(effects.bonuses||{})) acc.bonuses[k] = number(acc.bonuses[k]) + number(v)*bonusScale;
for (const [k,v] of Object.entries(effects.autoSuccesses||{})) acc.autoSuccesses[k] = number(acc.autoSuccesses[k]) + number(v)*poolScale;
if (effects.allSkills) acc.penaltyAll += number(effects.allSkills);
if (label) acc.lines.push(label);
}
function calcResources(cards) {
const active = cards || state.deck.active.map(id=>byId[id]).filter(Boolean);
const acc = { pools:{}, bonuses:{}, autoSuccesses:{}, wildcardPool:0, wildcardBonus:0, groupPV:0, penaltyAll:0, pairByReceiver:{}, lines:[] };
const groupCounts={}; active.forEach(c => (c.groups||[]).forEach(g => groupCounts[norm(g)]=(groupCounts[norm(g)]||0)+1));
for (const rule of groupRules) {
const count = groupCounts[norm(rule.group)] || 0;
if (count >= number(rule.minMembers || 2)) applyEffectToAccumulator(acc, rule.effects, Math.max(1, count-1), `${rule.group}: ${count} miembros`, 1);
}
const bases = new Set(active.map(c=>baseOf(c)).map(slug));
for (const pr of pairRules) {
if (!bases.has(slug(pr.receiver)) || !bases.has(slug(pr.related))) continue;
const target = acc.pairByReceiver[slug(pr.receiver)] ||= { pools:{}, bonuses:{}, autoSuccesses:{}, wildcardPool:0, wildcardBonus:0, groupPV:0, penaltyAll:0, lines:[] };
applyEffectToAccumulator(target, pr.effects, 1, `${pr.raw || `${pr.receiver}-${pr.related}`} (${pr.type || 'Bonus'})`, 1);
}
return acc;
}
function resourceChipClass(kind) { return kind === 'warn' ? 'cc-chip cc-chip--warn' : kind === 'bad' ? 'cc-chip cc-chip--bad' : 'cc-chip cc-chip--good'; }
function renderResourcePanel() {
const el = $('#ccResourcePanel'); if (!el) return;
const active = state.deck.active.map(id=>byId[id]).filter(Boolean);
const res = calcResources(active);
const bonusLines = Object.entries(res.bonuses).map(([k,v])=>`+${v} ${esc(k)} `).join('');
const poolLines = Object.entries(res.pools).map(([k,v])=>`Pull ${esc(k)}: ${v} `).join('');
const autoLines = Object.entries(res.autoSuccesses).map(([k,v])=>`Éxito ${esc(k)}: ${v} `).join('');
const pairCount = Object.values(res.pairByReceiver).reduce((a,b)=>a+b.lines.length,0);
const groupLines = res.lines.length ? res.lines.map(line => `${esc(line)} `).join('') : 'Sin bonus de grupo activo. ';
el.innerHTML = `Ventajas y recursos del equipo Grupos activos ${groupLines}
Bonos ${bonusLines || 'Sin bonos directos. '}
Pulls / éxitos ${poolLines || ''}${autoLines || ''}${(!poolLines&&!autoLines)?'Sin pulls. ':''}
Afinidades individuales ${pairCount} activas
`;
}
async function evolveBase(baseId, targetCategory) {
const sourceCategory = targetCategory === 'Oro' ? 'Plata' : 'Normal';
const source = cardByBaseCategory(baseId, sourceCategory);
const target = cardByBaseCategory(baseId, targetCategory);
if (!source || !target) return toast('Esta evolución aún no existe en el catálogo.', true);
const cost = number(target.upgradeCost || 5);
if (qtyByBaseCategory(baseId, sourceCategory) < cost) return toast(`Necesitas ${cost} copias ${sourceCategory} para fusionar.`, true);
try {
const data = await callApi('evolve-card', { baseId, sourceCategory, targetCategory });
normalizeState(data);
pruneDeck();
renderAll();
toast(data.message || `Carta fusionada a ${targetCategory}.`);
} catch(err) { toast(err.message, true); }
}
async function evolveCard(cardId) {
const card = byId[cardId]; if (!card) return;
const target = findEvolutionTarget(card); if (!target) return toast('Esta carta aún no tiene evolución creada en el catálogo.', true);
return evolveBase(baseOf(card), target.category);
}
function renderAll() { renderWalletPanel(); renderStats(); renderPacks(); renderMissions(); renderDeck(); renderCollectionAdminTools(); renderCollection(); }
function bindStaticEvents() {
// Trampa invisible + bloqueo por readonly contra autofill de Chrome/Edge.
const search = $('#ccSearchInput');
if (search) {
search.value = '';
search.setAttribute('autocomplete', 'new-password');
search.setAttribute('autocorrect', 'off');
search.setAttribute('autocapitalize', 'off');
search.setAttribute('spellcheck', 'false');
search.setAttribute('data-lpignore', 'true');
search.setAttribute('data-form-type', 'other');
search.setAttribute('name', 'cc_no_autofill_' + Date.now());
search.setAttribute('readonly', 'readonly');
state.filters.search = '';
const acceptManual = () => { ccSearchUserTouched = true; enableSearchManualInput(); };
['keydown', 'paste', 'drop', 'compositionstart'].forEach(ev => search.addEventListener(ev, acceptManual, { passive: true }));
search.addEventListener('focus', () => { enableSearchManualInput(); if (!ccSearchUserTouched) hardResetSearch('focus'); });
search.addEventListener('mousedown', () => { enableSearchManualInput(); if (!ccSearchUserTouched) hardResetSearch('mousedown'); });
search.addEventListener('input', e => {
const withinProtection = Date.now() < ccSearchProtectionUntil;
if (!ccSearchUserTouched && withinProtection) {
e.target.value = '';
state.filters.search = '';
renderCollection();
return;
}
state.filters.search = e.target.value || '';
renderCollection();
});
[0, 80, 250, 700, 1400, 2600].forEach(ms => setTimeout(() => {
if (!ccSearchUserTouched) { hardResetSearch('startup'); renderCollection(); }
}, ms));
}
$('#ccScrollCollectionBtn')?.addEventListener('click', () => $('#ccCollectionSection')?.scrollIntoView({ behavior:'smooth' }));
$('#ccClaimStarterBtn')?.addEventListener('click', async () => { try { const data = await callApi('grant-free-fayenne-card'); normalizeState(data); renderAll(); toast(data.message || 'Fayenne reclamada.'); } catch(err) { toast(err.message, true); } });
$('#ccOpenStandardBtn')?.addEventListener('click', () => openPackFlow('standard'));
$('#ccRarityFilter')?.addEventListener('change', e => { state.filters.rarity=e.target.value; renderCollection(); });
$('#ccVariantFilter')?.addEventListener('change', e => { state.filters.variant=e.target.value; renderCollection(); });
$('#ccOwnedFilter')?.addEventListener('change', e => { state.filters.owned=e.target.value; renderCollection(); });
$('#ccAutoDeckBtn')?.addEventListener('click', () => autoDeck(true));
$('#ccClearDeckBtn')?.addEventListener('click', clearDeck);
}
function renderStatBlocks(sections={}) { return Object.entries(sections).map(([name, values]) => `${esc(cap(name))} ${Object.entries(values||{}).filter(([k])=>!k.startsWith('_')).map(([k,v])=>`
${esc(k)} ${number(v)}
`).join('')}
`).join(''); }
function groupRulesForCard(card) {
return (card.groups || []).flatMap(g => groupRules.filter(r => norm(r.group) === norm(g)).map(r => ({ group:g, rule:r })));
}
function pairRulesForCard(card) {
const base = slug(baseOf(card));
return pairRules.filter(p => slug(p.receiver) === base);
}
function effectSummary(effects) {
const out = [];
for (const [k,v] of Object.entries(effects?.bonuses || {})) out.push(`+${v} ${k}`);
for (const [k,v] of Object.entries(effects?.pools || {})) out.push(`Pull ${k}: ${v}`);
for (const [k,v] of Object.entries(effects?.autoSuccesses || {})) out.push(`Éxito ${k}: ${v}`);
if (effects?.wildcardPool) out.push(`Comodín: ${effects.wildcardPool}`);
if (effects?.groupPV) out.push(`PV grupo: ${effects.groupPV}`);
if (effects?.allSkills) out.push(`${effects.allSkills} a todas`);
return out.join(' · ') || 'Sin efecto definido';
}
function openCardModal(cardId, startTab='day') {
const card = byId[cardId]; if (!card) return;
modal(`× ${esc(card.rarity)} ${esc(displayVariant(card))} x${ownedQty(card.id)}
${esc(card.name)} ${esc(card.description||'')}
☀ Día 🌙 Noche 📜 Bio ✨ Especial 👗 Imagen
`);
const panels = {
day: () => renderStatBlocks({ Arte: card.day?.arte, Persuasión: card.day?.persuasion, Conocimientos: card.day?.conocimientos, Artesanía: card.day?.artesania }),
night: () => renderStatBlocks({ Base: card.night?.base, Combate: card.night?.combate, Magia: card.night?.magia, Fe: card.night?.fe, Subterfugio: card.night?.subterfugio }),
bio: () => `${esc(card.bio || card.description || 'Sin bio todavía.')}
Grupos ${renderGroupIcons(card.groups, 30)} `,
special: () => renderSpecialPanel(card),
skins: () => renderSkinPanel(card)
};
function setTab(tab) { $$('#ccModalRoot [data-tab]').forEach(btn => btn.classList.toggle('is-active', btn.dataset.tab===tab)); $('#ccTabContent').innerHTML = panels[tab](); bindSkinButtons(); }
$$('#ccModalRoot [data-tab]').forEach(btn => btn.addEventListener('click', () => setTab(btn.dataset.tab)));
setTab(startTab);
}
function renderSpecialPanel(card) {
const groupRows = groupRulesForCard(card).map(x => `${esc(x.group)} : ${esc(effectSummary(x.rule.effects))} `).join('') || 'Sin bonus de grupo directo. ';
const pairRows = pairRulesForCard(card).slice(0,80).map(p => `${esc(p.raw || p.related)} : ${esc(effectSummary(p.effects))} `).join('') || 'Sin afinidades individuales configuradas. ';
return `${esc(card.special?.name || 'Especial')} ${esc(card.special?.description || card.special?.text || 'Pendiente de programar.')}
Bonus por grupos de este personaje Afinidades y penalizaciones `;
}
function renderSkinPanel(card) {
const allSkins = skinsByBase[card.baseId] || [];
const normalUnlocked = [];
const evolutionUnlocked = [];
const lockedNormal = [];
for (const s of allSkins) {
const evo = skinRequiresEvolution(s);
const owned = isSkinOwned(s.id);
if (evo) {
if (owned) evolutionUnlocked.push(s);
} else if (owned) {
normalUnlocked.push(s);
} else {
lockedNormal.push(s);
}
}
const shown = [...normalUnlocked, ...evolutionUnlocked];
const lockedPreview = lockedNormal.filter(s => !s.free && !s.default).slice(0, 12);
if (!shown.length && !lockedPreview.length) return `Este personaje aún no tiene variantes de imagen disponibles. `;
const cardHtml = (s, owned) => {
const selected = owned && (state.player.selectedSkins?.[card.baseId]===s.id || (!state.player.selectedSkins?.[card.baseId] && s.default));
const reason = skinRequiresEvolution(s) ? `Evolución ${esc(s.unlockCategory || '')}` : (s.free || s.default ? 'Base' : 'Sobre/evento');
const adminBtn = isAdminUser() && !owned && !skinRequiresEvolution(s) ? `Admin desbloquear ` : '';
return `${esc(s.label)} ${owned?'Disponible':`Bloqueada · ${reason}`} ${owned?`Usar imagen `:adminBtn} `;
};
return `Imagen preferida La imagen base está disponible al tener el personaje. Las imágenes Plata/Oro aparecen cuando tienes esa evolución. Los vestuarios normales se desbloquean por sobres, eventos, recompensas o admin.
${shown.length?`Disponibles ${shown.map(s => cardHtml(s, true)).join('')}
`:''}${lockedPreview.length?`Por conseguir ${lockedPreview.map(s => cardHtml(s, false)).join('')}
`:''} `;
}
function bindSkinButtons() {
$$('[data-select-skin]').forEach(btn => btn.addEventListener('click', async () => { try { const data = await callApi('select-card-skin', { baseId: btn.dataset.baseId, skinId: btn.dataset.selectSkin }); normalizeState(data); renderAll(); openCardModal(btn.dataset.baseId, 'skins'); toast('Imagen preferida actualizada.'); } catch(err) { toast(err.message, true); } }));
bindAdminCardButtons();
}
async function openPackFlow(packId) {
const pack = PACKS.find(p => p.id === packId && p.enabled); if (!pack) return toast('Sobre no disponible.', true); if (!state.user) return toast('Inicia sesión para abrir sobres.', true);
modal(`× ${esc(pack.name)}${pack.price} MA
`);
$('#ccDoOpenPackBtn')?.addEventListener('click', async () => { $('#ccDoOpenPackBtn').disabled=true; $('#ccPackOpenArea').innerHTML='Abriendo sobre...
'; try { const data = await callApi('buy-card-pack', { packId }); normalizeState(data); renderAll(); renderPackReveal(data.drawn || [], data.drawnDetails || []); } catch(err) { $('#ccDoOpenPackBtn').disabled=false; toast(err.message, true); } });
}
function renderPackReveal(ids, details=[]) {
const cards = ids.map(id=>byId[id]).filter(Boolean); const area = $('#ccPackOpenArea'); if (!area) return;
const detailByCardIndex = details || [];
area.innerHTML = `${cards.map((c,i)=>{ const skinId = detailByCardIndex[i]?.skinId || ''; return `
¿?
${esc(c.name)} ${esc(c.rarity)} · ${esc(displayVariant(c))}${skinId && skinsById[skinId] ? ` · ${esc(skinsById[skinId].label)}` : ''}
`;}).join('')}
`;
$$('[data-reveal-index]').forEach(el => el.addEventListener('click', () => el.classList.add('is-revealed')));
$('#ccRevealAllBtn')?.addEventListener('click', () => $$('[data-reveal-index]').forEach(el=>el.classList.add('is-revealed')));
$('#ccClosePackBtn')?.addEventListener('click', closeModal);
}
function weightedPick(obj) { const entries=Object.entries(obj||{}).filter(([,w])=>number(w)>0); const total=sum(entries.map(([,w])=>number(w))); let roll=Math.random()*total; for (const [k,w] of entries) { roll-=number(w); if (roll<=0) return k; } return entries[0]?.[0]; }
function makeChallenges(mission) {
const cats = Object.entries(mission.categories); const catWeights=Object.fromEntries(cats.map(([k,v])=>[k,v.weight||1]));
return Array.from({length: mission.challengeCount || 4}, (_,i)=>{ const catKey=weightedPick(catWeights); const cat=mission.categories[catKey]; return { id:`ch${i}`, catKey, label:cat.label, revealed:false, resolved:false, skill:null, target:null }; });
}
function revealChallenge(mission, ch) { const cat=mission.categories[ch.catKey]; ch.skill = weightedPick(cat.skills); ch.target = rnd(cat.targetMin, cat.targetMax); ch.revealed=true; }
function getCardSkill(card, catKey, skill) { return number(card.day?.[catKey]?.[skill] ?? card.night?.[catKey]?.[skill] ?? 0); }
function calcEffectiveSkill(card, catKey, skill, runtime, spend=0) {
const base = getCardSkill(card,catKey,skill);
const team = runtime.resources;
const pair = team.pairByReceiver[slug(card.baseId)] || { bonuses:{}, wildcardBonus:0, penaltyAll:0 };
const key = `${catKey}.${skill}`;
const direct = number(team.bonuses[key]) + number(pair.bonuses?.[key]);
const wildcardBonus = number(team.wildcardBonus) + number(pair.wildcardBonus);
const penalty = number(pair.penaltyAll);
return Math.max(0, base + direct + wildcardBonus + penalty + number(spend));
}
function autoSuccessKey(rt) {
const ch = rt.selectedChallenge;
if (!ch) return '';
const exact = `${ch.catKey}.${ch.skill}`;
const exactLeft = number(rt.resources.autoSuccesses[exact]) - number(rt.spent.autoSuccesses?.[exact]);
if (exactLeft > 0) return exact;
const catLeft = number(rt.resources.autoSuccesses[ch.catKey]) - number(rt.spent.autoSuccesses?.[ch.catKey]);
if (catLeft > 0) return ch.catKey;
return '';
}
function openMissionModal(missionId) {
const mission = missionCatalog.find(m=>m.id===missionId); if (!mission) return;
pruneDeck();
const active = state.deck.active.map(id=>byId[id]).filter(Boolean).slice(0,mission.teamSize);
const reserve = state.deck.reserve.map(id=>byId[id]).filter(Boolean).slice(0,mission.reserveSize);
const resources = calcResources(active);
const runtime = { mission, active: active.map(c=>({...c, hp:number(c.night?.base?.PV||1), fallen:false})), reserve:[...reserve], challenges:makeChallenges(mission), selectedChallenge:null, successes:0, failures:0, log:[], resources, groupPV:number(resources.groupPV), maxGroupPV:number(resources.groupPV), spent:{ wildcardPool:0, pools:{}, autoSuccesses:{} }, enemy: mission.enemy ? {...mission.enemy} : null, weakness:false };
modal(`×
`);
renderMissionRuntime(runtime);
}
function renderMissionRuntime(rt) {
const root = $('#ccMissionRuntime'); if (!root) return;
const m=rt.mission;
root.innerHTML = `${esc(m.name)} ${esc(m.description)}
${m.mode==='Día'?`Éxitos ${rt.successes}/${m.neededSuccesses}`:`${esc(rt.enemy?.name||'')} PV ${number(rt.enemy?.pv)}`}
${rt.enemy?`
${esc(rt.enemy.name)} Daño ${rt.enemy.damage} ${rt.weakness?'Punto débil descubierto':'Último PV bloqueado hasta Descubrir'}
`:''}
${rt.challenges.map(ch=>renderChallengeCard(ch, rt.selectedChallenge?.id===ch.id)).join('')}
${renderResolutionPanel(rt)}
Personajes activos ${rt.active.map((c,i)=>renderMissionCharacter(c,i,rt)).join('')}
Reserva ${rt.reserve.map(c=>`
${esc(c.name)} · ${esc(displayVariant(c))}
`).join('') || '
Sin reserva. '}
Recursos disponibles ${renderRuntimeResources(rt)}${rt.log.slice(-8).map(x=>`${esc(x)}
`).join('')} `;
$$('.cc-board-challenge').forEach(el => el.addEventListener('click', () => { const ch=rt.challenges.find(x=>x.id===el.dataset.challengeId); if (!ch || ch.resolved) return; if (!ch.revealed) revealChallenge(rt.mission,ch); rt.selectedChallenge=ch; renderMissionRuntime(rt); }));
$$('[data-mission-char]').forEach(btn => btn.addEventListener('click', () => resolveWithCharacter(rt, Number(btn.dataset.missionChar))));
$$('[data-use-pool]').forEach(btn => btn.addEventListener('click', () => { const k=btn.dataset.usePool; rt.spent.pools[k]=number(rt.spent.pools[k])+1; renderMissionRuntime(rt); }));
$$('[data-use-wildcard]').forEach(btn => btn.addEventListener('click', () => { rt.spent.wildcardPool++; renderMissionRuntime(rt); }));
$$('[data-use-auto-success]').forEach(btn => btn.addEventListener('click', () => resolveWithAutoSuccess(rt, btn.dataset.useAutoSuccess)));
}
function renderChallengeCard(ch, selected) { return `${esc(ch.label)} ${ch.revealed?`${esc(ch.skill)} ${ch.target}`:'?'} ${ch.resolved?'resuelto':ch.revealed?'elige personaje':'boca abajo'} `; }
function renderMissionCharacter(c,i,rt) { const dead=c.fallen||c.hp<=0; const ch=rt.selectedChallenge; const preview=ch?.revealed&&!ch.resolved&&!dead?`${getCardSkill(c,ch.catKey,ch.skill)} base
`:''; return `${esc(c.name)} ${esc(displayVariant(c))} PV ${c.hp} ${preview}Usar `; }
function renderRuntimeResources(rt) {
const ch=rt.selectedChallenge;
const wildcardLeft=number(rt.resources.wildcardPool)-number(rt.spent.wildcardPool);
const poolLeft = ch ? number(rt.resources.pools[ch.catKey]) - number(rt.spent.pools[ch.catKey]) : 0;
const autoKey = autoSuccessKey(rt);
const autoLeft = autoKey ? number(rt.resources.autoSuccesses[autoKey]) - number(rt.spent.autoSuccesses?.[autoKey]) : 0;
return `PV grupo ${rt.groupPV}/${rt.maxGroupPV} ${Object.entries(rt.resources.bonuses).map(([k,v])=>`+${v} ${esc(k)} `).join('')}${Object.entries(rt.resources.pools).map(([k,v])=>`Pull ${esc(k)}: ${v-number(rt.spent.pools[k])}/${v} `).join('')}${Object.entries(rt.resources.autoSuccesses).map(([k,v])=>`Éxito ${esc(k)}: ${v-number(rt.spent.autoSuccesses?.[k])}/${v} `).join('')}${rt.resources.wildcardPool?`Comodín: ${wildcardLeft}/${rt.resources.wildcardPool} `:''}
${ch?.revealed&&!ch.resolved?``:''}`;
}
function renderResolutionPanel(rt) {
const ch=rt.selectedChallenge;
if (!ch) return `Elige una de las 4 cartas de reto. Solo conoces la categoría hasta revelarla.
`;
if (ch.resolved) return `Reto resuelto. Elige otro.
`;
if (!ch.revealed) return `Reto boca abajo.
`;
const spend=number(rt.spent.wildcardPool)+number(rt.spent.pools[ch.catKey]);
return `${esc(ch.label)} → ${esc(ch.skill)} dificultad ${ch.target} Elige personaje o gasta un éxito automático si lo tienes. Gastado para este reto: +${spend}
`;
}
function resolveWithAutoSuccess(rt, key) {
const ch=rt.selectedChallenge; if (!ch || ch.resolved) return;
rt.spent.autoSuccesses[key] = number(rt.spent.autoSuccesses[key]) + 1;
if (rt.mission.mode === 'Día') { rt.successes++; rt.log.push(`Éxito automático usado en ${ch.skill}.`); }
else {
if (ch.catKey==='subterfugio' && ch.skill==='Descubrir') { rt.weakness=true; rt.log.push('Éxito automático: punto débil descubierto.'); }
else if (ch.skill==='Ofensivo' && rt.enemy) { rt.enemy.pv=Math.max(0, rt.enemy.pv-1); rt.log.push('Éxito automático ofensivo: 1 daño.'); }
else rt.log.push(`Éxito automático usado en ${ch.skill}.`);
}
ch.resolved=true; rt.spent={wildcardPool:0,pools:{},autoSuccesses:rt.spent.autoSuccesses}; renderMissionRuntime(rt);
}
function resolveWithCharacter(rt, idx) {
const ch=rt.selectedChallenge; const card=rt.active[idx]; if (!ch || !card || ch.resolved || card.fallen) return;
const spend=number(rt.spent.wildcardPool)+number(rt.spent.pools[ch.catKey]);
const value=calcEffectiveSkill(card,ch.catKey,ch.skill,rt,spend);
const target=number(ch.target);
let result='';
if (rt.mission.mode === 'Día') {
if (value > target) { rt.successes++; result=`${card.name} supera ${ch.skill} (${value} > ${target}) y sigue en juego.`; }
else if (value === target) { rt.successes++; result=`${card.name} empata ${ch.skill} (${value} = ${target}), consigue éxito pero cae.`; dropCharacter(rt, idx); }
else { rt.failures++; result=`${card.name} falla ${ch.skill} (${value} < ${target}) y cae.`; dropCharacter(rt, idx); }
} else {
resolveNight(rt, idx, card, ch, value, target);
result = rt.log.pop() || `${card.name} resuelve ${ch.skill}.`;
}
ch.resolved=true; rt.spent={wildcardPool:0,pools:{},autoSuccesses:{}}; rt.log.push(result); rt.resources=calcResources(rt.active.filter(c=>!c.fallen)); rt.maxGroupPV=Math.max(rt.maxGroupPV, number(rt.resources.groupPV));
if (rt.mission.mode==='Día' && rt.successes>=rt.mission.neededSuccesses) rt.log.push('Misión superada.');
if (rt.enemy && rt.enemy.pv<=0) rt.log.push('Enemigo derrotado.');
renderMissionRuntime(rt);
}
function resolveNight(rt, idx, card, ch, value, target) {
const skill=ch.skill;
if (ch.catKey==='subterfugio' && skill==='Descubrir' && value>=target) { rt.weakness=true; rt.log.push(`${card.name} descubre el punto débil (${value} ≥ ${target}).`); return; }
if (skill==='Sanación' && value>=target) { if (rt.groupPV < rt.maxGroupPV) { rt.groupPV++; rt.log.push(`${card.name} restaura 1 PV de grupo.`); } else { const wounded=rt.active.find(c=>!c.fallen && c.hp=target) { let dmg=number(card.night?.base?.['Daño combate'] || 1); if (rt.enemy?.requiresWeakness && !rt.weakness && rt.enemy.pv-dmg<=0) { rt.enemy.pv=1; rt.log.push(`${card.name} hiere al enemigo, pero no puede rematarlo sin Descubrir.`); } else { rt.enemy.pv=Math.max(0, rt.enemy.pv-dmg); rt.log.push(`${card.name} causa ${dmg} daño (${value} ≥ ${target}).`); } }
else { damageCharacter(rt, idx, number(rt.enemy?.damage||1)); rt.log.push(`${card.name} falla el ataque y recibe daño.`); }
} else {
if (value>=target) rt.log.push(`${card.name} evita el peligro (${value} ≥ ${target}).`);
else { damageCharacter(rt, idx, number(rt.enemy?.damage||1)); rt.log.push(`${card.name} no supera la defensa y recibe daño.`); }
}
}
function damageCharacter(rt, idx, dmg) { if (rt.groupPV > 0) { const used=Math.min(rt.groupPV,dmg); rt.groupPV-=used; dmg-=used; rt.log.push(`El PV de grupo absorbe ${used} daño.`); } if (dmg<=0) return; const c=rt.active[idx]; c.hp-=dmg; if (c.hp<=0) dropCharacter(rt, idx); }
function dropCharacter(rt, idx) { const old=rt.active[idx]; old.fallen=true; const replacement=rt.reserve.shift(); if (replacement) rt.active[idx]={...replacement,hp:number(replacement.night?.base?.PV||1),fallen:false}; }
async function loadGameState() { if (!state.user) return; try { const data = await callApi('get-card-game-state'); normalizeState(data); renderAll(); } catch(err) { toast(err.message, true); } }
function loadGuestFromCache() { const cache=loadCache(); if (cache?.player) normalizeState(cache); }
bindStaticEvents(); loadGuestFromCache(); renderAll();
onAuthStateChanged(auth, async user => { state.user=user||null; if (!user) { renderAll(); return; } await loadGameState(); });